전체 포스트

타입 추론 이해하기

타입스크립트의 타입 추론에 대해 이해해봅시다
2/28/2023 작성

개요

안녕하세요 이정환입니다 😃

타입스크립트를 처음 접하면 여기저기에 모두 타입을 선언 하곤 합니다. 타입 선언이 없는 자바스크립트만 사용하다보니 타입 선언 자체가 재미있고 신기해서 그럴 수도 있고, 반드시 타입 선언이 필요한 줄 아는 경우도 꽤 있습니다. 그런데 이런 대부분의 타입 선언은 사실 불필요합니다. 타입스크립트는 타입을 자동으로 추론할 줄 아는 똑똑한 녀석이기 때문이죠.

이번에는 타입스크립트의 자동으로 타입을 추론하는 아주 똑똑한 기능인 ‘타입 추론’에 대해 살펴보겠습니다.

타입 추론을 가능케 하는 점진적 타이핑

타입스크립트에서는 대부분의 상황에 변수의 타입을 직접 선언할 필요가 없습니다. 심지어 함수의 반환값의 타입도 굳이 선언하지 않아도 됩니다. 타입스크립트는 나름 똑똑한 친구 이므로 우리가 작성한 코드를 기반으로 자동으로 타입을 추론하기 때문입니다.

타입스크립트는 기본적으로 아주 명확하게 타입을 추론할 수 있는 상황에만 타입을 추론합니다. 예를 들어 다음과 같이 변수를 선언하고 number 타입의 값으로 초기화 했다면 이 변수는 누가 봐도 number 타입을 갖는 변수입니다.

COPY
let num = 12;

또는 다음과 같이 새로운 변수를 선언하고 string 타입의 값으로 초기화 했다면 해당 변수는 누가 봐도 string 타입을 갖는 변수입니다.

COPY
let str = "123";

타입스크립트 이렇게 코드를 기반으로 타입을 추론할 수 있는 경우 변수의 타입 선언을 생략하도록 허용합니다. 이런 타입스크립트의 특징을 점진적 타이핑이라고 합니다.

용어가 조금 어렵게 들릴 수 있지만 괜찮습니다. '점진적 타이핑'이라는 용어보다 더 중요한 것은 그 의미입니다. '점진적 타이핑'은 컴파일 타임(프로그램 실행 이전)에 타입을 검사하면서 필요에 따라 타입 선언을 생략할 수 있는 특징입니다. 즉, 추론이 가능할 때에는 타입 선언을 생략할 수 있는 것입니다.

정리하자면 타입스크립트는 타입을 확실히 알 수 있는 상황에는 타입을 자동으로 추론하며 이를 타입 추론이라고 합니다. 또 이런 특징을 점진적 타이핑이라고 합니다.

타입 추론이 가능한 상황들

그렇다면 어떤 상황에 타입 추론이 가능 할까요? 당연히 모든 상황에 타입을 추론할 수 있는건 아닙니다. 타입 추론이 가능한 상황을 예시와 함께 하나씩 살펴보겠습니다.

변수를 선언할 때

변수의 타입은 선언할 때 초기화하는 값을 기준으로 추론됩니다.

예를 들어 다음과 같이 number 타입 값으로 초기화 되는 변수의 타입은 number로 초기화 됩니다.

COPY
let a = 1;
// number 타입으로 추론

편집기(VsCode)에서 변수에 마우스 커서를 올리면 변수의 타입이 어떻게 추론 되었는지 확인할 수 있습니다.

단순한 원시 타입 값이 아닌 복잡한 객체로 초기화 해도 문제 없이 타입을 잘 추론합니다. 배열이나 함수도 마찬가지 입니다.

COPY
let user = {
  id: 1,
  name: "이정환",
  profile: {
    aka: "winterlood",
    age: 27,
    created_at: "2022-02-24",
  },
  urls: ["https://winterlood.com"],
};

변수에 할당한 객체의 구조를 기반으로 타입을 잘 추론하는 걸 확인할 수 있습니다.

그런데 만약 초기화를 생략하면 다음과 같이 any 타입으로 추론됩니다.

COPY
let noInitVar; // any

undefined 타입으로 추론되지 않는 이유는 값을 할당하기 전에 변수의 선언이 먼저 필요한 경우가 존재하기 때문입니다. 만약 undefined 타입으로 추론되었다면 아무런 값도 이 변수에 할당할 수 없게 됩니다. 이렇게 any 타입으로 추론된 변수는 이후 변수에 어떤 값을 할당 하느냐에 따라 타입이 변경됩니다. 이것을 **any의 진화(Evolve)**라고 하는데 나중에 자세히 다룹니다.

COPY
let noInitVar;
noInitVar = [1, 2, 3];

noInitVar; // number[] 타입으로 진화함

만약 const를 이용해 상수를 선언하면 리터럴 타입으로 추론됩니다. 설명 편의상 상수도 그냥 변수라고 부르겠습니다.

COPY
const num = 10;
// 10으로 추론됨

변수(상수) num을 number 타입 값 10으로 초기화 했습니다.

이때 변수 num의 타입은 어떤 타입으로 추론될까요? 앞서 살펴봤던 예제처럼 number 타입으로 추론될 것 같지만 그렇지 않습니다. num의 타입은 number 리터럴 타입 10으로 추론됩니다.

이유는 생각보다 간단합니다. const로 선언된 변수는 값을 바꿀 수 없기 때문입니다. 따라서 굳이 number 타입까지 가질 필요도 없습니다. 10이외에 다른 값을 갖지 않을 것이 분명하기 때문입니다.

이렇듯 타입스크립트에서는 변수의 타입을 자동으로 추론합니다. 따라서 대부분의 경우에는 타입을 프로그래머가 직접 명시하지 않아도 괜찮습니다. 불필요한 타입 선언이 너무 많아지면 오히려 코드의 가독성이 떨어지고 유지보수하기 어려운 코드가 탄생할 수 있기 때문입니다.

함수의 반환 값

함수의 반환값 또한 자동으로 추론됩니다. 예를 들어 다음과 같이 string 타입 값을 반환하는 함수가 있다면 이 함수의 반환값은 string 타입으로 자동 추론됩니다.

COPY
function hello() {
  return "hello world!";
}

아무것도 반환하지 않는 함수의 반환값은 void로 추론됩니다.

COPY
function voidFunc() {}
// void 타입으로 추론 됨

비 구조화 할당 할 때

객체를 비 구조화 할당할 때에도 변수의 타입이 자동으로 추론됩니다.

COPY
let person = {
  name: "이정환",
  age: 27,
};

let { name, age } = person;
// name : string, age : number로 추론된다

당연히 존재하지 않는 프로퍼티를 비 구조화 할당 받으려고 하면 오류가 발생합니다.

COPY
let person = {
  name: "이정환",
  age: 27,
};

let { name, age, test } = person;
// test는 존재하지 않습니다.

person 객체에 존재하지 않는 test 프로퍼티를 꺼내려고 하니 오류가 발생했습니다. 이렇듯 객체를 비 구조화 할당할 때 프로퍼티의 value 값을 기준으로 타입이 자동 추론됩니다.

또 객체 뿐만 아니라 배열의 비 구조화 할당 또한 타입 추론이 이루어집니다.

COPY
let arr = [1, 2, 3];
let [one, two, three] = arr;
// 모두 number 타입으로 추론된다.

arr은 number 타입의 값으로 이루어진 배열이므로 비 구조화 할당할 때 값이 할당되는 변수 one, two, three의 타입은 모두 number 타입으로 자동 추론됩니다.

매개변수에 기본값이 있을 때

이전에 함수의 매개변수 타입은 자동으로 추론되지 않는다고 살펴본 적 있습니다. 그런데 만약 매개변수에 기본값을 설정하면 설정된 기본값을 기준으로 타입이 자동 추론됩니다.

COPY
function func(parameter = 10) {
  return parameter.toString();
}

// parameter는 number로 추론된다.
// func 함수의 반환값은 string으로 추론된다.

toString 메서드를 이용해 parameter 의 값을 문자열로 변환해 반환 하므로 func 함수의 반환값은 string 타입으로 추론됩니다. 물론 이 외에도 함수 매개변수의 타입이 자동으로 추론되는 여러가지 상황이 있지만 지금은 기본적인 타입 추론에 대해 살펴보고 있기 때문에 몰라도 됩니다. 이후에 함수 타입을 다루며 더 자세히 살펴보겠습니다.

최적 공통 타입(Best Common Type)

만약 아래와 같은 변수의 타입은 어떻게 추론될까요?

COPY
let arr = [1, "string"];

변수 arr에는 배열이 저장되어 있습니다. 그리고 이 배열에는 number 타입과 string 타입 요소가 담겨 있습니다.이럴 경우 타입스크립트는 arr의 타입을 다음과 같이 추론합니다.

추론된 타입 (string | number)[]는 문자열이나 숫자 값을 저장할 수 있는 배열 타입입니다. 이렇게 타입스크립트는 배열의 타입을 추론할 때 각 배열 요소의 타입을 모두 고려하여 모든 요소의 타입과 호환되는 타입을 찾아 배열의 타입으로 추론합니다. 그리고 이런 타입을 ‘최적 공통 타입(Best Common Type)’이라고 부릅니다.

여기서 이런 의문이 들 수도 있습니다. string과 number가 모두 호환되는 타입은 unknown도 있고 any도 있습니다. 그들이 사실상 타입들의 전체 집합이기 때문입니다. 그럼 unknown이나 any로 추론하는게 더 편하지 않을까요?

그렇지 않습니다. 타입스크립트가 최적 공통 타입으로 number | string을 선택하는 원리는 생각보다 단순합니다. 만약 변수 arr의 타입을 unknown[]이나 any[]로 선언하면 number, string 타입 말고도 다른 타입의 값도 대입할 수 있기 때문입니다.

예제로 살펴보면 이해하기 쉽습니다. 다음은 arr이 unknown[] 타입으로 추론 되었다고 가정하는 코드입니다.

COPY
let arr: unknown[] = [1, "string"];
arr.push(true); // boolean 타입의 값도 저장 가능

변수 arr의 타입을 unknown[]로 선언했습니다. 이럴 경우 number, string이 아닌 타입의 모든 값을 이 배열에 저장할 수 있습니다. 따라서 이 배열은 사실상 아무 값이나 저장할 수 있는 배열이 됩니다. 이렇게 추론될 바에는 아예 추론되지 않는게 나을것 같습니다.

반면 다음과 같이 arr이 (string | number)[]로 추론 되었다고 가정 해 보겠습니다.

COPY
let arr: (string | number)[] = [1, "string"];

arr.push(1); // ✅
arr.push("hello"); // ✅
arr.push(true); // ❌

1, “hello”는 string 또는 number 타입의 값이므로 삽입이 허용됩니다. 그러나 true 같은 boolean 타입은 허용되지 않습니다.

이렇듯 타입스크립트는 여러개의 값을 동시에 만족해야 하는 타입을 추론할 때 모든 값의 타입과 호환되지만, 존재하지 않는 값은 배제하는 최적의 공통 타입을 찾아 추론합니다.

타입 넓히기

지금까지 타입 추론에 대해 살펴보았습니다. 타입스크립트는 대부분의 상황에 타입을 자동으로 추론합니다. 그렇다면 정확히 어떤 기준을 가지고 타입이 추론되는 걸까요?

다음 예제에서 타입스크립트는 변수 num의 타입을 number 타입으로 추론합니다.

COPY
let num = 10;
// number 타입으로 추론된다

생각해보면 number 리터럴 타입 ‘10’ 으로 추론 할 수도 있습니다. 실제로 const로 상수를 선언하면 그렇게 추론 하기도 합니다. 이 내용은 이미 앞서 살펴본 적 있습니다.

COPY
const num = 10;
// 10(number 리터럴 타입)으로 추론된다.

그렇다면 왜 let으로 선언한 변수의 타입은 number 타입으로 추론하는 것 일까요?

결론부터 말하자면 타입스크립트는 타입을 아주 정밀하게 추론하기 보다는 일반적으로, 범용적으로 추론하기 때문입니다. 정밀하게 추론 하기보다 일반적으로 또는 범용적으로 추론한다는 말은 구체적으로 무슨 뜻 일까요?

위 예제를 다시 살펴보겠습니다.

COPY
let num = 10;

변수 num의 타입을 10(number 리터럴 타입) 으로 추론하면 이것은 아주 정밀하게 타입을 추론한 것 입니다. 정밀하게 추론한다는 말은 바꿔 말하면 추론 가능한 타입들(any, unknown, number, number 리터럴) 중 가장 작은 집합을 갖는 가장 좁은 타입으로 추론한다는 뜻 입니다.

그러나 타입스크립트는 변수 num의 타입을 정밀하게 추론하지 않습니다. 그보다는 더 일반적으로 더 범용적으로 number 타입으로 추론합니다. 그 이유는 변수 num은 const가 아닌 let으로 선언했기 때문에 타입스크립트는 이후 이 변수에 10이 아닌 다른 number 값도 할당 될 수 있을 거라고 예상하기 때문입니다.

이렇게 타입스크립트가 변수의 타입을 추론할 때, 변수를 초기화 하는 값의 리터럴 타입보다 해당 리터럴 타입이 속하는 더 넓은 타입으로 타입을 추론하는 것을 ‘타입 넓히기’라고 합니다.

타입 넓히기를 잘 이해하고 있으면 타입 추론을 완벽히 제어할 수 있습니다. 여러분이 원하는 방향으로 타입을 추론하도록 유도할 수 있습니다. 그러나 만약 타입 추론을 이해하지 못한다면 예상치 못한 타입 추론으로 골머리를 앓다가 결국 포기하거나 최악의 경우에는 any 타입을 도배하여 타입 스크립트가 아닌 any 스크립트를 사용하게 될 수도 있습니다.

예상치 못한 타입 추론 방지하기

함수 getPropertyValue는 이름 그대로 객체와 키를 매개변수로 제공받습니다. 그 다음 키에 해당하는 객체의 프로퍼티의 값을 반환합니다.

COPY
type Person = {
  name: string;
  age: 27;
  hobby: string;
};

function getPropertyValue(person: Person, key: "name" | "age" | "hobby") {
  return person[key];
}

let person: Person = {
  name: "이정환",
  age: 27,
  hobby: "누워있기",
};

console.log(getPropertyValue(person, "age"));

// 27

이 코드를 실행하면 27이 출력됩니다.

그런데 만약 위 코드를 조금 수정해서 다음과 같이 한다면 오류가 발생할까요? 여러분도 함께 맞춰보세요

COPY
(...)

function getPropertyValue(person: Person, key: "name" | "age" | "hobby") {
  return person[key];
}

let person: Person = {
  name: "이정환",
  age: 27,
  hobby: "누워있기",
};

let key = "age"; // 추가된 코드

console.log(getPropertyValue(person, key)); // 변경된 코드

변수 key에 “age”를 저장합니다. 그 다음 getPropertValue에 두번째 인수로 이 변수를 전달합니다. 문제 될 건 없어 보입니다. 그런데 위 코드는 오류가 발생합니다.

COPY
(...)
let key = "age";

console.log(getPropertyValue(person, key));
// 오류 : string 타입은 "name" | "age" | "hobby" 타입에 할당 불가

오류가 발생한 이유는 타입스크립트가 변수 key의 타입을 추론하는 과정에서 타입 넓히기로 인해 타입을 “age”가 아닌 number로 추론하기 때문입니다. 따라서 “name” | “age” | “hobby” 타입이 선언된 매개변수에 할당할 수 없게 됩니다. number 타입은 number 리터럴 타입들로 만든 유니온 타입보다 더 넓은 타입(슈퍼 타입)이기 때문에 그렇습니다.

이런 경우 다음과 같이 앞선 예제처럼 인수에 값 자체를 전달하거나

COPY
(...)
console.log(getPropertyValue(person, "age"));

또는 다음과 같이 const 로 변수를 선언하는 방법이 있습니다.

COPY
(...)
const key = "age";
// "age"로 추론된다

console.log(getPropertyValue(person, key));

const로 선언한 변수에는 앞으로 다른 값이 할당될 수 없기 때문에 타입 넓히기가 이루어지지 않습니다. 따라서 key의 타입이 “age”로 추론되어 문제를 해결할 수 있습니다. 만약 반드시 let을 사용해야 하는 경우 이후에 다룰 ‘타입 단언’을 이용해 해결할 수 있습니다.